跳转至

3 View Transitions

In the previous chapter, you started working on a sports-themed app to sell game tickets. You managed to improve its pull-to-refresh animation, turning the system loading wheel into a fun and memorable interaction.

In this chapter, you’ll work on a new screen that contains a game’s details as the next step toward the ticket purchase. You’ll implement popular UI concepts like list filters, a collapsing header view and floating action buttons. Since this is an animations book, you’ll also enhance them via various types of transitions, which you already got to briefly play with in the first chapter. You’ll also get to roll up your sleeves and craft a custom transition.

Getting Started

You can continue working on the project from the previous chapter or use the starter project from this chapter’s materials.

To pick up where you left off at the previous chapter, grab the EventDetails folder and the Asset catalog, Assets.xcassets, from this chapter’s starter project and add them to your current project.

Since you’ll work on several different components this time, append the following values inside your Constants enum, over at Constants.swift:

static let spacingS = 8.0
static let spacingM = 16.0
static let spacingL = 24.0
static let cornersRadius = 24.0
static let iconSizeS = 16.0
static let iconSizeL = 24.0

static let orange = Color("AccentColor")

static let minHeaderOffset = -80.0
static let headerHeight = 220.0
static let minHeaderHeight = 120.0

static let floatingButtonWidth = 110.0

If you’re starting from the starter project, these files and values will already be part of your project.

Implementing Filter View

Head over to the already familiar ContentView.swift.

In the first iteration of the events screen, the navigation is somewhat cumbersome: the only way to find an event is to scroll to it, which can take a while. To make it more user-friendly, you’ll implement a filtering functionality. For example, a user who only wants to see basketball can filter out all other games.

First, create a new SwiftUI file named FilterView.swift, and add the following properties to the generated struct:

@Binding var selectedSports: Set<Sport>
var isShown: Bool

private let sports = Sport.allCases

Before moving on, you’ll fix the preview code so your code compiles. Replace the view in the preview code with:

FilterView(selectedSports: .constant([]), isShown: true)

Then, back in FilterView, below its body, add a method to build a view for each option:

func item(for sport: Sport) -> some View {
  Text(sport.string)
    .frame(height: 48)
    .foregroundColor(selectedSports.contains(sport) ? .white : .primary)
    .padding(.horizontal, 36)
}

Now, you’ll add a bit more style. Add the following .background modifier to the Textin item(for:):

.background {
  ZStack {
    RoundedRectangle(cornerRadius: Constants.cornersRadius)
      .fill(
        selectedSports.contains(sport)
        ? Constants.orange
        : Color(uiColor: UIColor.secondarySystemBackground)
      )
      .shadow(radius: 2)
    RoundedRectangle(cornerRadius: Constants.cornersRadius)
      .strokeBorder(Constants.orange, lineWidth: 3)
  }
}

This code makes the item appear as a rounded rectangle outlined by an orange stroke. If selectedSports contains the sport the user picked, it paints the view orange to indicate it was selected.

Now, replace the view’s body with a ZStack to hold all the sports options:

ZStack(alignment: .topLeading) {
  if isShown {
    ForEach(sports, id: \.self) { sport in
      item(for: sport)
        .padding([.horizontal], 4)
        .padding([.top], 8)
    }
  }
}
.padding(.top, isShown ? 24 : 0)

With the code above, you stack all the filtering items on top of each other. To build a grid out of them, you need to define each item’s location relative to its neighbors.

Aligning Subviews With Alignment Guides

Using the alignmentGuide(_:computeValue:) view modifier, you can shift a component relative to the positions of its sibling views. Since you want to implement a grid, you’ll adjust the item’s horizontal and vertical alignment guides.

Computations like this require iterating over all the elements to accumulate the total values of the horizontal and vertical shift, so add the following variables above the ZStack you added in the previous step, at the top of the body:

var horizontalShift = CGFloat.zero
var verticalShift = CGFloat.zero

You’ll start with the horizontal alignment. Add .alignmentGuide(_:computeValue:) to the item below its padding:

// 1
.alignmentGuide(.leading) { dimension in
  // 2
  if abs(horizontalShift - dimension.width) > UIScreen.main.bounds.width {
    // 3
    horizontalShift = 0
    verticalShift -= dimension.height
  }
  // 4
  let currentShift = horizontalShift
  // 5
  horizontalShift = sport == sports.last ? 0 : horizontalShift - dimension.width
  return currentShift
}

Here’s a step-by-step explanation:

  1. First, you tell SwiftUI you want to make a computation to change the .leadingalignment of a filter option. Inside the closure, you receive its dimensions, which will help calculate the alignment.
  2. You check whether the current item still fits horizontally.
  3. If it doesn’t, you move it to the next “row” by setting the horizontal shift to 0, which places it at the left corner of the parent container. Additionally, you deduct the view’s height from the vertical alignment to move the element down, forming a new row.
  4. Then, you assign the current item’s alignment value to a variable.
  5. You deduct the current view’s width from the alignment, which the next item in the loop will use.

img

Note

Although it may appear confusing at first, to move a view to the right, you need to move its horizontal alignment guide to the left. Therefore you deduct a view’s width from the alignment value. Once the alignment guide moves to the left, SwiftUI aligns it with the alignment guides of the view’s siblings by moving the view to the right.

Now, add alignmentGuide(_:computeValue:) to adjust the vertical alignment right below the previous one:

// 1
.alignmentGuide(.top) { _ in
  let currentShift = verticalShift
  // 2
  verticalShift = sport == sports.last ? 0 : verticalShift
  return currentShift
}

Here’s a code breakdown:

  1. This time, you adjust the .top alignment guide.
  2. Unless the current element is the last one, assign the value calculated alongside the horizontal alignment above. Otherwise, reset the shift value to 0.

Now you’ll handle the user’s selection. Add a new method to FilterView:

private func onSelected(_ sport: Sport) {
  if selectedSports.contains(sport) {
    selectedSports.remove(sport)
  } else {
    selectedSports.insert(sport)
  }
}

This code simply adds the sport to selectedSports or removes it if selectedSportsalready contains it.

Then, wrap your entire item(for:) with a Button and call your new onSelectedmethod, so it looks similar to the following:

Button {
  onSelected(sport)
} label: {
  item(for: sport)
    // padding and alignment guide modifiers
}

Filtering List Content

To use your new component, you’ll have to adapt ContentView a bit. Open ContentView.swift and add these new properties to it:

@State var filterShown = false // 1
@State var selectedSports: Set<Sport> = [] // 2
@State var unfilteredEvents: [Event] = [] // 3

Here’s what you’ll use each property for:

  1. You’ll use filterShown to toggle the visibility of FilterView.
  2. selectedSports is a set where you’ll keep the selected sports. Later, changing this property will filter the sports events.
  3. To reset the filter, you’ll keep the original array in unfilteredEvents.

Next, add a method to ContentView, which is responsible for filtering the events:

func filter() {
  events = selectedSports.isEmpty
    ? unfilteredEvents
    : unfilteredEvents.filter { selectedSports.contains($0.team.sport) }
}

To prevent the pull-to-refresh from breaking the filter functionality, replace the code inside update() with:

unfilteredEvents = await fetchMoreEvents(toAppend: events)
filter()

Add a toolbar item on the view’s primary ScrollView, using the toolbar(content:)modifier:

.toolbar {
   // 1
  ToolbarItem {
    Button {
      // 2
      filterShown.toggle()
    } label: {
      Label("Filter", systemImage: "line.3.horizontal.decrease.circle")
        .foregroundColor(Constants.orange)
    }
  }
}

Here’s a code breakdown:

  1. Inside .toolbar, you pass the toolbar items you want to display on top of the screen. You add only one primary action displayed as a filter icon.
  2. Once a user taps it, you toggle filterShown.

To trigger the filter, you’ll use the view modifier .onChange(of:) to listen to the changes to selectedSports. Add the following modifier to ScrollView:

.onChange(of: selectedSports) { _ in filter() }

Finally, wrap the LazyVStack holding the event views into another VStack and add FilterView on top so that the structure looks like this:

VStack {
  FilterView(selectedSports: $selectedSports, isShown: filterShown)
    .padding(.top)
    .zIndex(1)
  LazyVStack { ... }
}

Make sure the .animation and the .offset modifiers are attached to the outer VStack so the filters and pull-to-refresh won’t overlap.

Run the app, and tap the filter button in the navigation bar to see the new feature:

img

The functionality is there, but it’s not very fun to use. The filter view abruptly moves the events container down, which doesn’t look neat.

But it’s a piece of cake to make it smooth with SwiftUI’s transitions! Next, you’ll add a basic transition to your component.

Applying Basic Transitions

In SwiftUI, a transition is a movement that occurs as you add or remove a view in the rendering tree. It animates only in the context of a SwiftUI’s animation.

Since you already modify ContentView’s layout by showing and hiding the filter view and updating the content of the events set, only two components are missing: transitions and animations.

Still inside ContentView.swift, wrap the contents of filter() with withAnimation(_:_:) and pass some bouncy animation there:

withAnimation(
  .interpolatingSpring(stiffness: 30, damping: 8)
  .speed(1.5)
) {
  events = selectedSports.isEmpty
    ? unfilteredEvents
    : unfilteredEvents.filter { selectedSports.contains($0.team.sport)
  }
}

Modifying the events value inside withAnimation lets SwiftUI animate every view’s update that depends on the events property.

Next, inside ForEach, replace EventView with:

// 1
EventView(event: event)
// 2
  .transition(.scale.combined(with: .opacity))

Here, you:

  1. Create the event view just as you did before.
  2. Attach a scale transition to EventView and combine it with an opacity transition.

This changes the animation of EventView from easing into the view to scaling and slowly fading in.

Build and run, then filter by any sport to see the new transition.

img

Note

It’s sometimes preferable using VStack instead of LazyVStacks for animated content, since the lazy nature of the latter means the elements you want to animate aren’t necessarily available yet, which can cause the animation the look sloppy or stuck.

Crafting Custom Transitions

Your next goal is to animate FilterView, which gives you an opportunity to try out some more advanced transitions. With SwiftUI, you can create a custom modifier to animate the transition between the active and inactive (i.e. identity) states of your FilterView.

Back in FilterView.swift, add the following code to the bottom of the file:

struct FilterModifier: ViewModifier {
  // 1
  var active: Bool

  // 2
  func body(content: Content) -> some View {
    content
      // 3
      .scaleEffect(active ? 0.75 : 1)
       // 4
      .rotationEffect(.degrees(active ? .random(in: -25...25) : 0), anchor: .center)
  }
}

This code creates a struct named FilterModifier that conforms to ViewModifier. In the code above:

  1. You add an active property so you can animate the change between it’s trueand false states.
  2. For FilterModifier to conform to ViewModifier, you must implement body(content:), where you apply the preferable transformations to the content you receive as a parameter.
  3. You animate the change in scale between 0.75 and 1.
  4. Additionally, you make the view swing in a random direction and then get back to 0 degrees.

Now, add a new property in FilterView to keep the transition created with your view modifier:

private let filterTransition = AnyTransition.modifier(
  active: FilterModifier(active: true),
  identity: FilterModifier(active: false)
)

To apply the newly created transition, add the following modifier to the Buttoncontaining your filter item:

.transition(.asymmetric(
  insertion: filterTransition,
  removal: .scale
))

Typically, you apply the same transition to a view’s entry and exit. Since you only want the filter options to bounce when they appear, you need to apply an asymmetric transition. This way, you define insertion and removal transitions separately.

To start the transition, you need to animate the filterShown value change.

Back inside ContentView.swift, find:

filterShown.toggle()

Replace it with:

withAnimation(filterShown
  ? .easeInOut
  : .interpolatingSpring(stiffness: 20, damping: 3).speed(2.5)
) {
  filterShown.toggle()
}

With this approach, you alternate between the plain .easeInOut and the bouncy .interpolatingSpring animations. Try it out. Run the app and tap the filter button.

img

Nice job! The filter view appears with a bouncy spring animation and disappears with an ease animation. How much cooler is that? Next, you’ll improve the user experience on the event details screen.

Improving UX With Collapsible Header

To connect your ContentView to the new event details screen, wrap the EventViewinstance inside the ForEach with a NavigationLink as follows:

NavigationLink(destination: EventDetailsView(event: event)) {
  EventView(event: event)
}

Now, tapping on an event view cell in the container will navigate the user to the EventDetailsView. Try it out. Run the app to see what you’ve got to work on next.

img

On the new details screen, you’ll see all the relevant information on the specific event: the date, location, tickets available and the team’s upcoming games. You’ll also notice a button, Seating Chart. It doesn’t do much right now, but soon it’ll be a linking point to the component you’ll craft in the fourth and fifth chapters. Sounds intriguing?

For now, you have a lot to do on this screen.

Although the event details screen looks fine and fulfills its designated purpose - displaying event info - a good animation can improve its usability drastically.

Notice that EventDetailsView contains many components. Some are essential, while others are less critical. When a user scrolls down to see all the upcoming events, the most vital information and functionality gets lost: the date and button to navigate to the seating chart to buy tickets. If too many events are already planned, it can take a while to scroll back to the important section.

There are multiple viable approaches to solving this problem. You could split the screen’s functionality and, for example, show the upcoming games only on demand, thus making the details screen smaller and more manageable.

Alternatively, you could hide them completely, add a search bar on the events list screen and make users look for the stuff they need. You could also “pin” the crucial components to stay visible and accessible while a user scrolls down the screen’s content, which is the strategy you’ll take for this chapter.

Building a Collapsible Header With GeometryReader

To make a header view collapse with the same velocity as a user scrolls down content, you need to shrink it vertically by the value of the scroll view’s offset.

Since you’re now an expert on SwiftUI’s GeometryReader, the first steps may already be clear to you: create a new SwiftUI view file and name it HeaderGeometryReader.swift. It’s responsible for catching the offset value of your scroll view.

Add these properties to the newly generated struct:

@Binding var offset: CGFloat
@Binding var collapsed: Bool

@State var startOffset: CGFloat = 0

EventDetailsView is aware of the current offset and if the header is collapsed.

Before moving on, remove the generated HeaderGeometryReader_Previews because you won’t need it for this specific view.

Then, replace body’s content with:

GeometryReader<AnyView> { proxy in
 // 1
  guard proxy.frame(in: .global).minX >= 0 else {
    return AnyView(EmptyView())
  }

  Task {
    // 2
    offset = proxy.frame(in: .global).minY - startOffset

    withAnimation(.easeInOut) {
      // 3
      collapsed = offset < Constants.minHeaderOffset
    }
  }

  return AnyView(Color.clear.frame(height: 0)
    .task {
      // 4
      startOffset = proxy.frame(in: .global).minY
    }
  )
}

In the code snippet above, you:

  1. Verify that the frame of the proxy is valid as a safety measure. If you navigate to a different screen while some transitions are animating on the previous screen, the proxy’s values may be off upon entering the screen. You ignore such values until the valid ones appear.
  2. Calculate the change to the scroll view’s offset by subtracting the starting value from the current offset, minY of the proxy’s frame. This way, before a user interacts with the content, the value of offset is 0.
  3. The header should collapse if the offset gets below the minimum value. You wrap this change in withAnimation to allow seamless transitions between the collapsed and expanded states.
  4. To fetch the starting value of the offset only once before the view appears, you attach a .task modifier and access the proxy’s minY from within it.

Now, open EventDetailsView.swift and add the offset and collapsed properties:

@State private var offset: CGFloat = 0
@State private var collapsed = false

Next, wrap the content of yourScrollView with a ZStack, so it looks like so:

ScrollView {
  ZStack {
    VStack {...}
  }
}

Note

You may find the code folding ribbon option particularly helpful while working on this chapter. With it, you can easily fold the code blocks when you need to wrap them into another component. To enable it, tap on the checkbox in Xcode Preferences -> Text Editing -> Display -> Code Folding Ribbon.

Then, add your HeaderGeometryReader inside the ZStack above the VStack:

HeaderGeometryReader(
  offset: $offset,
  collapsed: $collapsed
)

Before EventDetailsView‘s body grows even further, create a new SwiftUI file and name it HeaderView.swift. This file is responsible for the views you’ll pin to the top of the screen.

Add the following properties inside the struct:

var event: Event
var offset: CGFloat
var collapsed: Bool

Next, replace the content of body with:

ZStack {
  // 1
  AsyncImage(
    url: event.team.sport.imageURL,
    content: { image in
      image.resizable()
        .scaledToFill()
        .frame(width: UIScreen.main.bounds.width)
        // 2
        .frame(height: max(
          Constants.minHeaderHeight,
          Constants.headerHeight + offset
        ))
        .clipped()
    },
    placeholder: {
      ProgressView().frame(height: Constants.headerHeight)
    }
  )
}

Here’s what’s happening:

  1. You add an AsyncImage from EventDetailsView to HeaderView and wrap it into a ZStack.
  2. You use the new headerHeight for the height instead of the hardcoded one. This makes the image shrink as the user scrolls the content of EventDetailsView and updates the height of AsyncImage in .frame so that it changes alongside the offset but doesn’t go below the minimum allowed value.

Now, below clipped(), add some shadow and round the corners of the image to make it appear elevated above the content:

.cornerRadius(collapsed ? 0 : Constants.cornersRadius)
.shadow(radius: 2)

Since you’re going to display the title and date label in the header, you need to apply an overlay to the image to darken it and make the text more readable. Add an .overlaymodifier to the end of AsyncImage:

.overlay {
  RoundedRectangle(cornerRadius: collapsed ? 0 : Constants.cornersRadius)
    .fill(.black.opacity(collapsed ? 0.4 : 0.2))
}

Since you’re building a custom header, namely a toolbar, you need to hide the system header. Head over to EventDetailsView.swift and add the two following modifiers on the root ZStack of EventDetailsView:

.toolbar(.hidden)
.edgesIgnoringSafeArea(.top)

You also use edgesIgnoringSafeArea(_:) to make the content of EventDetailsViewmove toward the top border of the screen.

Now, it’s time to add the missing views in your HeaderView. Head back to HeaderView.swift and add a VStack below the AsyncImage. It’ll be the container for the team’s name and date labels and the back button because you need to replace the system one, which is now gone:

VStack(alignment: .leading) {

}
.padding(.horizontal)
.frame(height: max(
  Constants.minHeaderHeight,
  Constants.headerHeight + offset
))

Next, you’ll want to dismiss the view once the user taps the back button. Add the following environment property to HeaderView above the event property:

@Environment(\.dismiss) var dismiss

Note

SwiftUI provides several environment values which can come in handy, like color scheme and size class. You can access them using a keypath in the @Environment attribute.

Next, add a new Button inside the VStack together with the back button and the title label:

Button {
  // 1
  dismiss()
} label: {
  HStack {
    Image(systemName: "chevron.left")
      .resizable()
      .scaledToFit()
      .frame(height: Constants.iconSizeS)
      .clipped()
      .foregroundColor(.white)

    // 2
    if collapsed {
      Text(event.team.name)
        .frame(maxWidth: .infinity, alignment: .leading)
        .font(.title2)
        .fontWeight(.bold)
        .foregroundColor(.white)
    } else {
      Spacer()
    }
  }
  .frame(height: 36.0)
  // 3
  .padding(.top, UIApplication.safeAreaTopInset + Constants.spacingS)
}

Here’s a code breakdown:

  1. You wrap an HStack inside a Button which triggers the dismiss() method when tapped.
  2. You show the team’s name alongside the back button image in case the header is collapsed. Otherwise, you replace it with the Spacer.
  3. Since you make your header ignore the safe area insets via edgesIgnoringSafeArea(_:), you need to account for this measurement yourself to prevent the notch of your iPhone from hiding the back button and title.

The only thing missing in the header is the date label. Add the following code right below the Button you added in the previous step:

// 1
Spacer()

// 2
if collapsed {
  HStack {
    Image(systemName: "calendar")
      .renderingMode(.template)
      .resizable()
      .scaledToFit()
      .frame(height: Constants.iconSizeS)
      .foregroundColor(.white)
      .clipped()

    Text(event.date)
      .foregroundColor(.white)
      .font(.subheadline)
  }
  .padding(.leading, Constants.spacingM)
  .padding(.bottom, Constants.spacingM)
}

Here’s what you did:

  1. You use a Spacer to keep the space when the header is expanded and prevent the back button from jumping along the y-axis between state changes.
  2. If collapsed is true, you display a calendar icon and the date.

Finally, update HeaderView_Previews’s Header initializer:

HeaderView(
  event: Event(team: teams[0], location: "Somewhere", ticketsLeft: 345),
  offset: -100,
  collapsed: true
)

Note

Check out the preview to see this view. Tweak the values of HeaderView in the preview to check its states.

Now, go back to EventDetailsView.swift and remove the AsyncImage. In its place, add HeaderView in the root ZStack above ScrollView:

HeaderView(
  event: event,
  offset: offset,
  collapsed: collapsed
)
.zIndex(1)

A few lines down, add a Spacer above the team name to prevent the header from overlapping the screen’s content:

Spacer()
  .frame(height: Constants.headerHeight)

Updating EventLocationAndDate to Animate When Collapsed

Now, open EventLocationAndDate.swift and add the following property below the event:

var collapsed: Bool

Update the preview with:

EventLocationAndDate(
  event: makeEvent(for: teams[0]),
  collapsed: false
)

Inside the second HStack, wrap the calendar Image and the date Text into an ifstatement to remove them when the header collapses, leaving the Spacer outside of the condition:

if !collapsed {
  ...
}

Go back to EventDetailsView.swift and pass collapsed to the initializer of the EventLocationAndDate:

EventLocationAndDate(event: event, collapsed: collapsed)

Last but not least, hide the team’s name label once the header collapses as well:

if !collapsed {
  Text(event.team.name)
    .frame(maxWidth: .infinity, alignment: .leading)
    .font(.title2)
    .fontWeight(.black)
    .foregroundColor(.primary)
    .padding()
}

Run the app or check the preview of EventDetailsView:

img

Synchronizing Geometry of Views With .matchedGeometryEffect

To make it appear as if you’re “pinning” a label when it reaches the top of the screen, you need to align its disappearance in EventDetailsView and appearance in HeaderView. However, those are two different labels existing in two separate components!

It can seem challenging to implement this, but SwiftUI offers an out-of-the-box solution for this very problem — matchedGeometryEffect. This modifier matches two views by updating the frame of one view to match the frame of another.

SwiftUI recognizes which views it needs to adjust by their identifier in the common namespace.

Obtaining the animation namespace of a view is very straightforward. Simply add the following property to EventDetailsView above the event property:

@Namespace var namespace

Now that you have an animation namespace, you can add the .matchedGeometryEffectmodifier to the team’s name text below its padding:

.matchedGeometryEffect(
  id: "title",
  in: namespace,
  properties: .position
)

Note

When linking two views with .matchedGeometryEffect, you can specify whether you want to align their sizes, positions or both. By default, it matches their frames, which works well for most use cases. When animating views containing text, you may want to use the .position option to prevent the text from being truncated while transitioning.

To make the title transition above the header, set its zIndex to move the label on top of the header:

.zIndex(2)

You need to share the same namespace ID between EventDetailsView and HeaderView to link their matched geometry together. Add a new property to HeaderView below the dismiss property:

var namespace: Namespace.ID

Then, add a matching .matchedGeometryEffect to the team name Text in HeaderView:

.matchedGeometryEffect(
  id: "title",
  in: namespace,
  properties: .position
)

To animate the transition for the calendar icon and the date label, add .matchedGeometryEffect to them as well:

.matchedGeometryEffect(id: "icon", in: namespace)
.matchedGeometryEffect(
  id: "date",
  in: namespace,
  properties: .position
)

To match them with the views inside EventLocationAndDate, follow the same steps as for HeaderView.

Go to EventLocationAndDate and:

  1. Add namespace of type Namespace.ID.
  2. Add .matchedGeometryEffect to the calendar icon, the date label with the "icon" and "date" identifiers, respectively:
.matchedGeometryEffect(id: "icon", in: namespace)
.matchedGeometryEffect(
  id: "date",
  in: namespace,
  properties: .position
)

If you want to have a preview of a view requiring a namespace, for example, EventLocationAndDate and HeaderView, you’ll need to make @Namespace static:

struct EventLocationAndDate_Previews: PreviewProvider {
  @Namespace static var namespace
  static var previews: some View {
    EventLocationAndDate(
      namespace: namespace,
      event: makeEvent(for: teams[0]),
      collapsed: false
    )
  }
}

Finally, pass namespace from EventDetailsView to both HeaderView’s and EventLocationAndDate’s initializers:

HeaderView(
  namespace: namespace,
  event: event,
  offset: offset,
  collapsed: collapsed
)
EventLocationAndDate(
  namespace: namespace,
  event: event,
  collapsed: collapsed
)

Refresh the preview of EventDetailsView or run the app.

You’ve got the transitions working, but the button still goes out of sight when you scroll down to the upcoming games list. To keep it accessible to the user at all times, you’ll implement the floating action button concept.

Implementing Floating Action Button

You’ll wrap up this chapter by adding a Floating Action Button to order tickets for the event, and transition to it as the user scrolls - so the user has the order button right at their finger tips, wherever they are.

Inside EventDetailsView, wrap the button in an if statement:

if !collapsed {
  Button(action: {}) {
    ...
  }
}

Then, add .matchedGeometryEffect to the Text inside the button’s label with a new identifier:

.matchedGeometryEffect(
  id: "button",
  in: namespace,
  properties: .position
)

To make the button shrink smoothly while scrolling, add a constant .frame(width:) to RoundedRectangle:

.frame(
  width: max( // 2
    Constants.floatingButtonWidth,
    min( // 1
      UIScreen.halfWidth * 1.5,
      UIScreen.halfWidth * 1.5 + offset * 2
    )
  )
)
  1. The min function returns the smaller value out of the two parameters it receives. This ensures that as the offset value grows, the button’s width doesn’t go over UIScreen.halfWidth * 1.5 or 75% of the screen width.
  2. The max function does the exact opposite - it’s helpful to ensure the bottom limit of a value. Once the offset value grows negatively, it caps the minimum value of the button’s width to the Constants.floatingButtonWidth.

This way, although the button’s width depends on the offset, you limit its possible values to the range between the Constants.floatingButtonWidth and 75% of the screen width.

Next, add a computed property inside EventDetailsView to store the collapsed representation of the button:

var collapsedButton: some View {
  HStack {
    Spacer()
    Button(action: { seatingChartVisible = true  }) {
      Image("seats")
        .resizable()
        .renderingMode(.template)
        .scaledToFit()
        .frame(width: 32, height: 32)
        .foregroundColor(.white)
        .padding(.horizontal)
        .background {
          RoundedRectangle(cornerRadius: 36)
            .fill(Constants.orange)
            .shadow(radius: 2)
            .frame(width: Constants.floatingButtonWidth, height: 48)
        }
        .matchedGeometryEffect(
          id: "button",
          in: namespace,
          properties: .position
        )
    }
    .padding(36)
  }
}

In the collapsed state, you replace the label with an icon. And obviously we won’t forget to link it to the original button with a .matchedGeometryEffect of the same id.

Finally, replace HeaderView in EventDetailsView’s body with the code below:

VStack {
  HeaderView(
    namespace: namespace,
    event: event,
    offset: offset,
    collapsed: collapsed
  )
  Spacer()
  if collapsed {
    collapsedButton
  }
}
.zIndex(1)

Time to run the app one more time!

img

Key Points

  1. Transitions define the way a view appears and disappears from the screen.
  2. You can combine your transitions with .combined(with:).
  3. Use .matchedGeometryEffect to align one view’s frame to the other view’s frame into a seamless animation.
  4. A view will have different insertion and removal animations if you specify both via .asymmetric(with:).
  5. To implement a custom transition, you need to create a ViewModifier with the different transformations applied for the active and identity states.